#!/usr/bin/env python3

# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception

import argparse
import configparser
import filecmp
import glob
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path


def directory_compare(
        reference: str | Path, actual: str | Path, ignore, allow_untracked_files: bool):
    reference, actual = Path(reference), Path(actual)

    compared = filecmp.dircmp(reference, actual, ignore=ignore)
    if (compared.left_only
        or (compared.right_only and not allow_untracked_files)
        or compared.diff_files):
        return False
    for common_dir in compared.common_dirs:
        path1 = reference / common_dir
        path2 = actual / common_dir
        if not directory_compare(path1, path2, ignore, allow_untracked_files):
            return False
    return True

class BemanSubmodule:
    def __init__(
            self, dirpath: str | Path, remote: str, commit_hash: str,
            allow_untracked_files: bool):
        self.dirpath = Path(dirpath)
        self.remote = remote
        self.commit_hash = commit_hash
        self.allow_untracked_files = allow_untracked_files

def parse_beman_submodule_file(path):
    config = configparser.ConfigParser()
    read_result = config.read(path)
    def fail():
        raise Exception(f'Failed to parse {path} as a .beman_submodule file')
    if not read_result:
        fail()
    if not 'beman_submodule' in config:
        fail()
    if not 'remote' in config['beman_submodule']:
        fail()
    if not 'commit_hash' in config['beman_submodule']:
        fail()
    allow_untracked_files = config.getboolean(
        'beman_submodule', 'allow_untracked_files', fallback=False)
    return BemanSubmodule(
        Path(path).resolve().parent,
        config['beman_submodule']['remote'],
        config['beman_submodule']['commit_hash'],
        allow_untracked_files)

def get_beman_submodule(path: str | Path):
    beman_submodule_filepath = Path(path) / '.beman_submodule'

    if beman_submodule_filepath.is_file():
        return parse_beman_submodule_file(beman_submodule_filepath)
    else:
        return None

def find_beman_submodules_in(path):
    path = Path(path)
    assert path.is_dir()

    result = []
    for dirpath, _, filenames in path.walk():
        if '.beman_submodule' in filenames:
            result.append(parse_beman_submodule_file(dirpath / '.beman_submodule'))
    return sorted(result, key=lambda module: module.dirpath)

def cwd_git_repository_path():
    process = subprocess.run(
        ['git', 'rev-parse', '--show-toplevel'], capture_output=True, text=True,
        check=False)
    if process.returncode == 0:
        return process.stdout.strip()
    elif "fatal: not a git repository" in process.stderr:
        return None
    else:
        raise Exception("git rev-parse --show-toplevel failed")

def clone_beman_submodule_into_tmpdir(beman_submodule, remote):
    tmpdir = tempfile.TemporaryDirectory()
    subprocess.run(
        ['git', 'clone', beman_submodule.remote, tmpdir.name], capture_output=True,
        check=True)
    if not remote:
        subprocess.run(
            ['git', '-C', tmpdir.name, 'reset', '--hard', beman_submodule.commit_hash],
            capture_output=True, check=True)
    return tmpdir

def get_paths(beman_submodule):
    tmpdir = clone_beman_submodule_into_tmpdir(beman_submodule, False)
    paths = set(glob.glob('*', root_dir=Path(tmpdir.name), include_hidden=True))
    paths.remove('.git')
    return paths

def beman_submodule_status(beman_submodule):
    tmpdir = clone_beman_submodule_into_tmpdir(beman_submodule, False)
    if directory_compare(
            tmpdir.name, beman_submodule.dirpath, ['.beman_submodule', '.git'],
            beman_submodule.allow_untracked_files):
        status_character=' '
    else:
        status_character='+'
    parent_repo_path = cwd_git_repository_path()
    if not parent_repo_path:
        raise Exception('this is not a git repository')
    relpath = Path(beman_submodule.dirpath).relative_to(Path(parent_repo_path))
    return status_character + ' ' + beman_submodule.commit_hash + ' ' + str(relpath)

def beman_submodule_update(beman_submodule, remote):
    tmpdir = clone_beman_submodule_into_tmpdir(beman_submodule, remote)
    tmp_path = Path(tmpdir.name)
    sha_process = subprocess.run(
        ['git', 'rev-parse', 'HEAD'], capture_output=True, check=True, text=True,
        cwd=tmp_path)

    if beman_submodule.allow_untracked_files:
        for path in get_paths(beman_submodule):
            path2 = Path(beman_submodule.dirpath) / path
            if Path(path2).is_dir():
                shutil.rmtree(path2)
            elif Path(path2).is_file():
                os.remove(path2)
    else:
        shutil.rmtree(beman_submodule.dirpath)

    submodule_path = tmp_path / '.beman_submodule'
    with open(submodule_path, 'w') as f:
        f.write('[beman_submodule]\n')
        f.write(f'remote={beman_submodule.remote}\n')
        f.write(f'commit_hash={sha_process.stdout.strip()}\n')
        if beman_submodule.allow_untracked_files:
            f.write(f'allow_untracked_files=True\n')
    shutil.rmtree(tmp_path / '.git')
    shutil.copytree(tmp_path, beman_submodule.dirpath, dirs_exist_ok=True)

def update_command(remote, path):
    if not path:
        parent_repo_path = cwd_git_repository_path()
        if not parent_repo_path:
            raise Exception('this is not a git repository')
        beman_submodules = find_beman_submodules_in(parent_repo_path)
    else:
        beman_submodule = get_beman_submodule(path)
        if not beman_submodule:
            raise Exception(f'{path} is not a beman_submodule')
        beman_submodules = [beman_submodule]
    for beman_submodule in beman_submodules:
        beman_submodule_update(beman_submodule, remote)

def add_command(repository, path, allow_untracked_files):
    tmpdir = tempfile.TemporaryDirectory()
    subprocess.run(
        ['git', 'clone', repository], capture_output=True, check=True, cwd=tmpdir.name)
    repository_name = os.listdir(tmpdir.name)[0]
    if not path:
        path = Path(repository_name)
    else:
        path = Path(path)
    if not allow_untracked_files and path.exists():
        raise Exception(f'{path} exists')
    path.mkdir(exist_ok=allow_untracked_files)
    tmpdir_repo = Path(tmpdir.name) / repository_name
    sha_process = subprocess.run(
        ['git', 'rev-parse', 'HEAD'], capture_output=True, check=True, text=True,
        cwd=tmpdir_repo)
    with open(tmpdir_repo / '.beman_submodule', 'w') as f:
        f.write('[beman_submodule]\n')
        f.write(f'remote={repository}\n')
        f.write(f'commit_hash={sha_process.stdout.strip()}\n')
        if allow_untracked_files:
            f.write(f'allow_untracked_files=True\n')
    shutil.rmtree(tmpdir_repo /'.git')
    shutil.copytree(tmpdir_repo, path, dirs_exist_ok=True)

def status_command(paths):
    if not paths:
        parent_repo_path = cwd_git_repository_path()
        if not parent_repo_path:
            raise Exception('this is not a git repository')
        beman_submodules = find_beman_submodules_in(parent_repo_path)
    else:
        beman_submodules = []
        for path in paths:
            beman_submodule = get_beman_submodule(path)
            if not beman_submodule:
                raise Exception(f'{path} is not a beman_submodule')
            beman_submodules.append(beman_submodule)
    for beman_submodule in beman_submodules:
        print(beman_submodule_status(beman_submodule))

def get_parser():
    parser = argparse.ArgumentParser(description='Beman pseudo-submodule tool')
    subparsers = parser.add_subparsers(dest='command', help='available commands')
    parser_update = subparsers.add_parser('update', help='update beman_submodules')
    parser_update.add_argument(
        '--remote', action='store_true',
        help='update a beman_submodule to its latest from upstream')
    parser_update.add_argument(
        'beman_submodule_path', nargs='?',
        help='relative path to the beman_submodule to update')
    parser_add = subparsers.add_parser('add', help='add a new beman_submodule')
    parser_add.add_argument('repository', help='git repository to add')
    parser_add.add_argument(
        'path', nargs='?', help='path where the repository will be added')
    parser_add.add_argument(
        '--allow-untracked-files', action='store_true',
        help='the beman_submodule will not occupy the subdirectory exclusively')
    parser_status = subparsers.add_parser(
        'status', help='show the status of beman_submodules')
    parser_status.add_argument('paths', nargs='*')
    return parser

def parse_args(args):
    return get_parser().parse_args(args);

def usage():
    return get_parser().format_help()

def run_command(args):
    if args.command == 'update':
        update_command(args.remote, args.beman_submodule_path)
    elif args.command == 'add':
        add_command(args.repository, args.path, args.allow_untracked_files)
    elif args.command == 'status':
        status_command(args.paths)
    else:
        raise Exception(usage())

def check_for_git(path):
    env = os.environ.copy()
    if path is not None:
        env["PATH"] = path
    return shutil.which("git", path=env.get("PATH")) is not None

def main():
    try:
        if not check_for_git(None):
            raise Exception('git not found in PATH')
        args = parse_args(sys.argv[1:])
        run_command(args)
    except Exception as e:
        print("Error:", e, file=sys.stderr)
        sys.exit(1)

if __name__ == '__main__':
    main()
